/* eslint-disable class-methods-use-this,no-restricted-syntax,guard-for-in */
const pathToRegexp = require('path-to-regexp');
const requireYML = require('require-yml');
const BusinessError = require('../exception/businessError');
const Response = require('./response');

const PARAM_TYPE = {
  BODY: 'Body',
  QUERY: 'Query',
  PATH: 'Path',
};

const PARAM_CONDITION_RELATION = {
  AND: 'and',
  OR: 'or',
};

class LambdaDispatcher {
  constructor(configPath, services) {
    const config = requireYML(configPath);
    this.pathMap = this._flattenPathMap(config);
    this.services = services;
  }

  dispatch(event, context, callback) {
    this._doDispatch(event, context, callback);
  }

  lambda() {
    return (event, context, callback) => {
      this._doDispatch(event, context, callback);
    };
  }

  async _doDispatch(event, context, callback) {
    const headers = {
      'x-amzn-log-stream-name': context.logStreamName,
      'x-amzn-invoke-id': context.invokeid,
    };

    try {
      context.callbackWaitsForEmptyEventLoop = false;

      console.info('entry do dispatch', event, context);

      this._prettyConvertEvent(event);

      console.info('pretty convert event', event);

      const lambda = this._getLambda(event);

      console.info('get lambda', lambda);

      if (lambda) {
        const sp = lambda.split(':');
        const lambdaFunction = this.services[sp[0]][sp[1]];
        console.info('lambda function', lambdaFunction);
        const result = await lambdaFunction(event, context);
        callback(null, new Response(result, headers));
      } else {
        throw new BusinessError(404, 'requested resource not exist');
      }
    } catch (err) {
      console.error('do dispatch error', err);
      if (err instanceof BusinessError) {
        callback(null, new Response(err, headers, 400));
      } else {
        callback(null, new Response(err, headers, 500));
      }
    }
  }

  _getLambda(event) {
    const { path, httpMethod: method } = event;
    const key = `${method} ${path}`;

    for (const k in this.pathMap) {
      const keys = [];
      const re = pathToRegexp(k, keys, { sensitive: true });
      if (re.test(key)) {
        const v = this.pathMap[k];
        if (Array.isArray(v)) {
          for (const c of v) {
            if (this._paramsIsSatisfy(event, c.param)) {
              return c.lambda;
            }
          }
        } else {
          this._parsePathParameters(event, { key, keys, re });
          return v.lambda;
        }
      }
    }
    return null;
  }

  _parsePathParameters(event, { key, keys, re }) {
    const v = re.exec(key);
    keys.forEach(({ name }, i) => {
      event.pathParameters[name] = v[i + 1];
    });
  }

  _paramsIsSatisfy(event, { re, type, condition }) {
    const requestParams = {};

    if (type === PARAM_TYPE.BODY) {
      Object.keys(condition).forEach((k) => {
        requestParams[k] = event.body[k];
      });
    } else if (type === PARAM_TYPE.QUERY) {
      Object.keys(condition).forEach((k) => {
        requestParams[k] = event.queryStringParameters[k];
      });
    } else if (type === PARAM_TYPE.PATH) {
      Object.keys(condition).forEach((k) => {
        requestParams[k] = event.pathParameters[k];
      });
    }

    if (Object.keys(requestParams).length > 0) {
      return this._paramsValueIsCorrect(requestParams, re, condition);
    }
    return false;
  }

  _paramsValueIsCorrect(params, re, condition) {
    if (re === PARAM_CONDITION_RELATION.AND) {
      for (const paramName in condition) {
        if (condition[paramName] !== params[paramName]) {
          return false;
        }
      }

      return true;
    } else if (re === PARAM_CONDITION_RELATION.OR) {
      for (const paramName in condition) {
        if (condition[paramName] === params[paramName]) {
          return true;
        }
      }

      return false;
    }

    throw new Error(`invalid re type: ${re}`);
  }

  _flattenPathMap(config) {
    const pathMap = {};
    config.forEach((pathConfig) => {
      const rootPath = Object.keys(pathConfig)[0];
      pathConfig[rootPath].forEach((v) => this._resolvePath(pathMap, rootPath, v));
    });

    return pathMap;
  }

  _resolvePath(pathMap, currentPath, { method, lambda, paths: childrenPaths, params }) {
    if (lambda) {
      this._validateMethodParam(currentPath, method);

      pathMap[`${method} ${currentPath}`] = {
        lambda,
      };
    }

    if (params) {
      this._validateMethodParam(currentPath, method);

      const lambdas = [];

      params.forEach((param) => {
        this._validateLambdaParam(currentPath, param.lambda);

        lambdas.push({
          lambda: param.lambda,
          param: {
            re: param.re,
            type: param.type,
            condition: param.condition,
          },
        });

        pathMap[`${method} ${currentPath}`] = lambdas;
      });
    }

    if (childrenPaths) {
      childrenPaths.forEach((childrenPath) => {
        const path = Object.keys(childrenPath)[0];
        const fullPath = currentPath + path;
        childrenPath[path].forEach((v) => this._resolvePath(pathMap, fullPath, v));
      });
    }
  }

  _validateMethodParam(path, method) {
    if (!method) {
      throw new Error(`${path}: method is required`);
    }
  }

  _validateLambdaParam(path, lambda) {
    if (!lambda) {
      throw new Error(`${path}: lambda is required of params`);
    }
  }

  _prettyConvertEvent(event) {
    event.body = event.body ? JSON.parse(event.body) : {};
    event.pathParameters = event.pathParameters ? event.pathParameters : {};
    event.queryStringParameters = event.queryStringParameters ? event.queryStringParameters : {};
  }
}

module.exports = LambdaDispatcher;
